Read Buf

Read Buf

如何避免 Nginx 配置中的十大常见错误

operation

在帮助 NGINX 用户解决问题时,我们经常看到一些常见的配置错误——这些错误在其他用户的配置中屡见不鲜,有时甚至出现在 NGINX 工程师编写的配置中!在这篇博客中,我们将探讨 10 个最常见的错误,解释问题所在以及如何修复它们。

  • 每个工作进程的文件描述符不足
  • error_log off 指令
  • 未启用与上游服务器的 keepalive 连接
  • 忘记指令继承的工作原理
  • proxy_buffering off 指令
  • 错误使用 if 指令
  • 过多的健康检查
  • 度量指标的访问不安全
  • 当所有流量来自同一 /24 CIDR 块时使用 ip_hash
  • 未利用上游组

错误 1:每个工作进程的文件描述符不足

worker_connections 指令设置了 NGINX 工作进程可以打开的最大并发连接数(默认是 512)。所有类型的连接(例如,与代理服务器的连接)都计入最大值,而不仅仅是客户端连接。但需要记住,最终每个工作进程的并发连接数还受操作系统为每个进程分配的最大文件描述符(FD)数量限制。在现代 UNIX 发行版中,默认限制是 1024。

对于所有但最小的 NGINX 部署,512 个连接的限制可能太小了。实际上,我们随 NGINX 开源二进制文件和 NGINX Plus 分发的默认 nginx.conf 文件将其增加到 1024。

常见的配置错误是没有将 FDs 的限制至少增加到 worker_connections 值的两倍。解决方法是在主配置上下文中使用 worker_rlimit_nofile 指令设置该值。

下面是需要更多 FDs 的原因:每个从 NGINX 工作进程到客户端或上游服务器的连接都会消耗一个 FD。当 NGINX 作为 Web 服务器时,它使用一个 FD 进行客户端连接,另一个 FD 用于每个服务的文件,最低需要两个 FD 每个客户端(但大多数网页由许多文件组成)。当它作为代理服务器时,NGINX 为客户端连接和上游服务器连接各使用一个 FD,可能还会使用第三个 FD 存储服务器的临时响应。作为缓存服务器,NGINX 对缓存响应的行为类似于 Web 服务器,而当缓存为空或过期时则类似于代理服务器。

NGINX 还为每个日志文件使用一个 FD,并使用几个 FD 与主进程通信,但这些数量通常比连接和文件使用的 FDs 数量小得多。

UNIX 提供了几种为每个进程设置 FDs 数量的方法:

  • 如果从 shell 启动 NGINX,可以使用 ulimit 命令
  • 如果作为服务启动 NGINX,可以使用 init 脚本或 systemd 服务清单变量
  • /etc/security/limits.conf 文件

然而,使用哪种方法取决于您如何启动 NGINX,而 worker_rlimit_nofile 无论如何启动 NGINX 都有效。

此外,还有一个系统范围内的 FDs 限制,您可以通过操作系统的 sysctl fs.file-max 命令设置。通常这个值足够大,但值得验证所有 NGINX 工作进程可能使用的最大文件描述符数(worker_rlimit_nofile * worker_processes)明显小于 fs.file-max。如果 NGINX 以某种方式使用了所有可用的 FDs(例如,在 DoS 攻击期间),甚至无法登录到机器来修复问题。

错误 2:error_log off 指令

常见的错误是认为 error_log off 指令禁用了日志记录。实际上,与 access_log 指令不同,error_log 不接受 off 参数。如果在配置中包含 error_log off 指令,NGINX 会在 NGINX 配置文件的默认目录(通常是 /etc/nginx)中创建一个名为 off 的错误日志文件。

我们不建议禁用错误日志,因为它是调试 NGINX 问题的重要信息来源。然而,如果存储空间非常有限,可能会记录足够的数据以耗尽可用磁盘空间,那么禁用错误日志记录可能有意义。请在主配置上下文中包含以下指令:

error_log /dev/null emerg;

注意,这个指令在 NGINX 读取和验证配置之前不适用。因此,每次 NGINX 启动或重新加载配置时,可能会记录到默认错误日志位置(通常是 /var/log/nginx/error.log),直到配置被验证。要更改日志目录,请在 nginx 命令上包含 -e <error_log_location> 参数。

错误 3:未启用与上游服务器的 Keepalive 连接

默认情况下,NGINX 为每个新传入请求打开一个到上游(后端)服务器的新连接。这是安全的,但效率低下,因为 NGINX 和服务器必须交换三个数据包以建立连接,并交换三到四个数据包以终止连接。

在高流量情况下,为每个请求打开一个新连接可能会耗尽系统资源,使其无法打开新的连接。原因如下:每个连接的四元组(源地址、源端口、目标地址和目标端口)必须是唯一的。对于从 NGINX 到上游服务器的连接,四个元素中的三个(第一个、第三个和第四个)是固定的,只剩下源端口是可变的。当连接关闭时,Linux 套接字在 TIME-WAIT 状态下停留两分钟,这在高流量情况下增加了耗尽可用源端口池的可能性。如果发生这种情况,NGINX 无法打开到上游服务器的新连接。

解决方法是启用 NGINX 与上游服务器之间的 keepalive 连接——请求完成后连接保持打开状态,以便用于额外的请求。这既减少了耗尽源端口的可能性,又提高了性能。

要启用 keepalive 连接:

在每个 upstream{} 块中包含 keepalive 指令,设置每个工作进程缓存中保留的空闲 keepalive 连接数。

注意,keepalive 指令不会限制 NGINX 工作进程可以打开的到上游服务器的总连接数——这是一个常见的误解。因此,keepalive 参数不需要像您想象的那样大。

我们建议将参数设置为 upstream{} 块中列出的服务器数量的两倍。这足够大,可以让 NGINX 维护与所有服务器的 keepalive 连接,但又足够小,使上游服务器也能处理新传入的连接。

还需要注意的是,当您在 upstream{} 块中指定负载均衡算法(使用 haship_hashleast_connleast_timerandom 指令)时,该指令必须出现在 keepalive 指令上方。这是 NGINX 配置中指令顺序无关紧要的一般规则的一个罕见例外。

在转发请求到上游组的 location{} 块中,与 proxy_pass 指令一起包含以下指令:

proxy_http_version 1.1;
proxy_set_header   "Connection" "";

默认情况下,NGINX 使用 HTTP/1.0 连接到上游服务器,并相应地在转发到服务器的请求中添加 Connection: close 头。结果是每个连接在请求完成后都会关闭,尽管 upstream{} 块中存在 keepalive 指令。

proxy_http_version 指令告诉 NGINX 使用 HTTP/1.1,而 proxy_set_header 指令则从 Connection 头中移除 close 值。

错误 4: 忘记指令继承的工作原理

NGINX 的指令是向下继承的,或者说是“从外到内”继承:子上下文(嵌套在另一个上下文内的上下文,即其父上下文)继承父级上下文中包含的指令设置。例如,http{} 上下文中的所有 server{} 和 location{} 块都继承了 http 级别包含的指令值,而 server{} 块中的指令会被该块中的所有子 location{} 块继承。然而,当同一指令同时包含在父上下文和其子上下文中时,这些值不会叠加,而是子上下文中的值会覆盖父级值。

错误在于忘记了这种“覆盖规则”对于数组指令的适用性,这些数组指令不仅可以包含在多个上下文中,还可以在给定上下文中多次包含。例如,proxy_set_header 和 add_header——由于第二个指令名中包含“add”,这使得人们特别容易忘记覆盖规则。

我们可以通过以下 add_header 示例来说明继承的工作原理:

http {    
    add_header X-HTTP-LEVEL-HEADER 1;
    add_header X-ANOTHER-HTTP-LEVEL-HEADER 1;

    server {
        listen 8080;
        location / {
            return 200 "OK";
        } 
    }

    server {
        listen 8081;
        add_header X-SERVER-LEVEL-HEADER 1;

        location / {
            return 200 "OK";
        }

        location /test {
            add_header X-LOCATION-LEVEL-HEADER 1;
            return 200 "OK";
        }

        location /correct {
            add_header X-HTTP-LEVEL-HEADER 1;
            add_header X-ANOTHER-HTTP-LEVEL-HEADER 1;

            add_header X-SERVER-LEVEL-HEADER 1;
            add_header X-LOCATION-LEVEL-HEADER 1;
            return 200 "OK";
        } 
    }
}

对于监听端口 8080 的服务器,由于在 server{} 或 location{} 块中没有 add_header 指令,因此继承是直接的,我们可以看到在 http{} 上下文中定义的两个头:

% curl -is localhost:8080
HTTP/1.1 200 OK
Server: nginx/1.21.5
Date: Mon, 21 Feb 2022 10:12:15 GMT
Content-Type: text/plain
Content-Length: 2
Connection: keep-alive
X-HTTP-LEVEL-HEADER: 1
X-ANOTHER-HTTP-LEVEL-HEADER: 1
OK

对于监听端口 8081 的服务器,在 server{} 块中有一个 add_header 指令,但在其子 location / 块中没有。server{} 块中定义的头覆盖了 http{} 上下文中定义的两个头:

% curl -is localhost:8081
HTTP/1.1 200 OK
Server: nginx/1.21.5
Date: Mon, 21 Feb 2022 10:12:20 GMT
Content-Type: text/plain
Content-Length: 2
Connection: keep-alive
X-SERVER-LEVEL-HEADER: 1
OK

在子 location /test 块中,有一个 add_header 指令,它覆盖了其父 server{} 块中的头以及 http{} 上下文中的两个头:

% curl -is localhost:8081/test
HTTP/1.1 200 OK
Server: nginx/1.21.5
Date: Mon, 21 Feb 2022 10:12:25 GMT
Content-Type: text/plain
Content-Length: 2
Connection: keep-alive
X-LOCATION-LEVEL-HEADER: 1
OK

如果我们希望 location{} 块保留其父上下文中定义的头以及任何在本地定义的头,则必须在 location{} 块中重新定义父上下文的头。这就是我们在 location /correct 块中所做的:

% curl -is localhost:8081/correct
HTTP/1.1 200 OK
Server: nginx/1.21.5
Date: Mon, 21 Feb 2022 10:12:30 GMT
Content-Type: text/plain
Content-Length: 2
Connection: keep-alive
X-HTTP-LEVEL-HEADER: 1
X-ANOTHER-HTTP-LEVEL-HEADER: 1
X-SERVER-LEVEL-HEADER: 1
X-LOCATION-LEVEL-HEADER: 1
OK

错误 5: proxy_buffering off 指令

在 NGINX 中,代理缓冲默认是启用的(proxy_buffering 指令设置为 on)。代理缓冲意味着 NGINX 在接收到来自服务器的响应时将其存储在内部缓冲区中,直到整个响应被缓冲完毕才开始向客户端发送数据。缓冲有助于优化与慢速客户端的性能——因为 NGINX 会缓冲响应,直到客户端接收完所有数据,这样代理服务器可以尽快返回其响应,并继续处理其他请求。

当代理缓冲被禁用时,NGINX 只在开始向客户端发送服务器响应的第一部分之前进行缓冲,默认情况下,这个缓冲区的大小为一个内存页(4 KB 或 8 KB,具体取决于操作系统)。这通常只够容纳响应头。随后,NGINX 会在接收到响应时同步地将其发送给客户端,这迫使服务器在等待 NGINX 接收下一个响应段时处于闲置状态。

因此,我们对在配置中看到 proxy_buffering off 感到惊讶。也许这是为了减少客户端体验的延迟,但效果微乎其微,而副作用却很多:禁用代理缓冲后,即使配置了,速率限制和缓存也无法工作,性能也会受到影响,等等。

只有少数几个用例可能需要禁用代理缓冲(例如长轮询),所以我们强烈建议不要更改默认设置。有关更多信息,请参阅 NGINX Plus 管理指南。

错误 6: 不当使用 if 指令

if 指令在使用上非常棘手,尤其是在 location{} 块中。它常常不会按预期工作,甚至可能导致段错误。事实上,NGINX Wiki 上有一篇名为《If is Evil》的文章,详细讨论了这些问题以及如何避免它们,我们建议你去阅读这篇文章。

通常情况下,在 if{} 块内部你可以安全使用的指令只有 return 和 rewrite。下面的例子使用 if 来检测包含 X‑Test 头的请求(但这可以是你想测试的任何条件)。NGINX 返回 430(请求头字段过大)错误,在命名位置 @error_430 拦截该错误并将请求代理到名为 b 的上游组。

location / {    
    error_page 430 = @error_430;
    if ($http_x_test) {
        return 430; 
    }

    proxy_pass http://a;
}

location @error_430 {
    proxy_pass b;
}

对于这种情况以及许多其他使用 if 的场景,通常可以完全避免使用该指令。下面的例子中,当请求包含 X‑Test 头时,map{} 块将 $upstream_name 变量设置为 b,然后请求将被代理到名为 b 的上游组。

map $http_x_test $upstream_name {    
    default "b";
    ""      "a";
}

# ...

location / {
    proxy_pass http://$upstream_name;
}

错误 7: 过多的健康检查

配置多个虚拟服务器以将请求代理到相同的上游组是非常常见的做法(换句话说,在多个 server{} 块中包含相同的 proxy_pass 指令)。在这种情况下的错误是在每个 server{} 块中都包含一个 health_check 指令。这只会增加上游服务器的负担而不会带来任何额外的信息。

显而易见的解决方法是在每个 upstream{} 块中只定义一个健康检查。这里我们在一个特殊的命名位置为名为 b 的上游组定义了健康检查,配有适当的超时和头设置。

location / {    
    proxy_set_header Host $host;
    proxy_set_header "Connection" "";
    proxy_http_version 1.1;
    proxy_pass http://b;
}

location @health_check {
    health_check;
    proxy_connect_timeout 2s;
    proxy_read_timeout 3s;
    proxy_set_header Host example.com;
    proxy_pass http://b;
}

在复杂配置中,可以进一步简化管理,将所有健康检查位置分组到单个虚拟服务器中,并与 NGINX Plus API 和仪表板一起使用,如下例所示。

server {    
    listen 8080;
 
    location / {
        # …
    }
 
    location @health_check_b {
        health_check;
        proxy_connect_timeout 2s;
        proxy_read_timeout 3s;
        proxy_set_header Host example.com;
        proxy_pass http://b;
    }
 
    location @health_check_c {
        health_check;
        proxy_connect_timeout 2s;
        proxy_read_timeout 3s;
        proxy_set_header Host api.example.com;
        proxy_pass http://c;
    }
 
    location /api {
        api write=on;
        # 指定限制 API 访问的指令(见下文的错误 8)
    }
 
    location = /dashboard.html {
        root   /usr/share/nginx/html;
    }
}

有关 HTTP、TCP、UDP 和 gRPC 服务器健康检查的更多信息,请参阅 NGINX Plus 管理指南。

错误 8: 未保护的指标访问

关于 NGINX 操作的基本指标可以从 Stub Status 模块获取。对于 NGINX Plus,你还可以通过 NGINX Plus API 收集更广泛的指标。通过在 server{} 或 location{} 块中分别包含 stub_status 或 api 指令来启用指标收集,这将成为访问指标的 URL。(对于 NGINX Plus API,你还需要为你想要收集指标的 NGINX 实体——虚拟服务器、上游组、缓存等——配置共享内存区;请参阅 NGINX Plus 管理指南中的说明。)

一些指标是敏感信息,可能会被用来攻击你的网站或由 NGINX 代理的应用程序,用户配置中有时会出现未限制对相应 URL 访问的错误。这里我们将介绍一些保护指标的方法。我们将在第一个例子中使用 stub_status。

使用以下配置,任何人都可以通过 http://example.com/basic_status 访问指标。

server {    
    listen 80;
    server_name example.com;

    location = /basic_status {
        stub_status;
    }
}

使用 HTTP 基本认证保护指标

为了使用 HTTP 基本认证保护指标,可以包含 auth_basicauth_basic_user_file 指令。文件(这里是 .htpasswd)列出了可以登录查看指标的客户端用户名和密码:

server {    
    listen 80;
    server_name example.com;

    location = /basic_status {
        auth_basic "closed site";
        auth_basic_user_file conf.d/.htpasswd;
        stub_status;
    }
}

使用 allow 和 deny 指令保护指标

如果您不希望授权用户必须登录,并且您知道他们将从哪些 IP 地址访问指标,另一种选择是使用 allow 指令。您可以指定单个 IPv4 和 IPv6 地址以及 CIDR 范围。deny all 指令可以防止其他地址的访问。

server {    
    listen 80;
    server_name example.com;

    location = /basic_status {
        allow 192.168.1.0/24;
        allow 10.1.1.0/16;
        allow 2001:0db8::/32;
        allow 96.1.2.23/32;
        deny  all;
        stub_status;
    }
}

结合两种方法

如果我们想结合两种方法怎么办?我们可以允许来自特定地址的客户端无需密码访问指标,同时要求来自其他地址的客户端登录。为此,我们使用 satisfy any 指令。它告诉 NGINX 允许使用 HTTP 基本认证登录的客户端或使用预批准 IP 地址的客户端访问。为了增加安全性,您可以将 satisfy 设置为 all,以便即使是来自特定地址的人也需要登录。

server {    
    listen 80;
    server_name monitor.example.com;

    location = /basic_status {
        satisfy any;

        auth_basic "closed site";
        auth_basic_user_file conf.d/.htpasswd;
        allow 192.168.1.0/24;
        allow 10.1.1.0/16;
        allow 2001:0db8::/32;
        allow 96.1.2.23/32;
        deny  all;
        stub_status;
    }
}

使用 NGINX Plus,您可以使用相同的技术限制对 NGINX Plus API 端点(以下示例中的 http://monitor.example.com:8080/api/ )以及实时活动监控仪表板 http://monitor.example.com/dashboard.html 的访问。

此配置仅允许来自 96.1.2.23/32 网络或本地主机的客户端无需密码访问。由于指令是在 server{} 级别定义的,因此这些限制同样适用于 API 和仪表板。顺便提一下,apiwrite=on 参数意味着这些客户端还可以使用 API 进行配置更改。

有关配置 API 和仪表板的更多信息,请参阅 NGINX Plus 管理指南。

server {    
    listen 8080;
    server_name monitor.example.com;
 
    satisfy any;
    auth_basic "closed site";
    auth_basic_user_file conf.d/.htpasswd;
    allow 127.0.0.1/32;
    allow 96.1.2.23/32;
    deny  all;

    location = /api/ {    
        api write=on;
    }

    location = /dashboard.html {
        root /usr/share/nginx/html;
    }
}

错误 9: 当所有流量来自同一 /24 CIDR 块时使用 ip_hash

ip_hash 算法基于客户端 IP 地址的哈希在 upstream{} 块中的服务器之间平衡流量。哈希键是 IPv4 地址的前三个八位字节或整个 IPv6 地址。该方法建立了会话持久性,这意味着来自客户端的请求总是传递到同一台服务器,除非服务器不可用。

假设我们在配置为高可用性的虚拟专用网络中部署了 NGINX 作为反向代理。我们在 NGINX 前面放置了各种防火墙、路由器、四层负载均衡器和网关,以接受来自不同来源(内部网络、合作伙伴网络、互联网等)的流量,并将其传递给 NGINX 进行反向代理到上游服务器。以下是初始 NGINX 配置:

http {
    upstream {
        ip_hash;
        server 10.10.20.105:8080;
        server 10.10.20.106:8080;
        server 10.10.20.108:8080;
    }
 
    server {# …}
}

但事实证明存在一个问题:所有的“拦截”设备都在同一个 10.10.0.0/24 网络,因此在 NGINX 看来,所有流量都来自该 CIDR 范围内的地址。请记住,ip_hash 算法对 IPv4 地址的前三个八位字节进行哈希。在我们的部署中,每个客户端的前三个八位字节都是相同的——10.10.0——因此所有它们的哈希值都是相同的,没有基础将流量分配到不同服务器。

解决方法是使用 hash 算法,哈希键为 $binary_remote_addr 变量。该变量捕获完整的客户端地址,将其转换为二进制表示形式,IPv4 地址为 4 字节,IPv6 地址为 16 字节。现在,每个拦截设备的哈希值都不同,负载均衡工作如预期。

我们还包括 consistent 参数,以使用 ketama 哈希方法而不是默认方法。这大大减少了当服务器集发生变化时重新映射到不同上游服务器的键数,从而提高了缓存服务器的缓存命中率。

http {    
    upstream {
        hash $binary_remote_addr consistent;
        server 10.10.20.105:8080;
        server 10.10.20.106:8080;
        server 10.10.20.108:8080;
    }

    server {# …}
}

错误 10: 不利用上游组的优势

假设您使用 NGINX 进行一种最简单的用例,作为侦听 3000 端口的单个基于 NodeJS 的后端应用程序的反向代理。一个常见的配置可能如下所示:

http {
    server {
        listen 80;
        server_name example.com;

        location / {
            proxy_set_header Host $host;
            proxy_pass http://localhost:3000/;
        }
    }
}

看起来很简单,对吧?proxy_pass 指令告诉 NGINX 将客户端请求发送到哪里。所有 NGINX 需要做的就是将主机名解析为 IPv4 或 IPv6 地址。一旦建立连接,NGINX 将请求转发到该服务器。

这里的错误在于假设因为只有一台服务器——因此没有理由配置负载均衡——所以创建 upstream{} 块是无意义的。事实上,upstream{} 块解锁了几个提高性能的功能,如下配置所示:

http {
    upstream node_backend {
        zone upstreams 64K;
        server 127.0.0.1:3000 max_fails=1 fail_timeout=2s;
        keepalive 2;
    }

    server {
        listen 80;
        server_name example.com;

        location / {
            proxy_set_header Host $host;
            proxy_pass http://node_backend/;
            proxy_next_upstream error timeout http_500;
        }
    }
}

zone 指令建立了一个共享内存区,主机上的所有 NGINX 工作进程都可以访问有关上游服务器的配置和状态信息。多个上游组可以共享该区域。使用 NGINX Plus,该区域还使您能够使用 NGINX Plus API 更改上游组中的服务器和单个服务器的设置,而无需重新启动 NGINX。有关详细信息,请参阅 NGINX Plus 管理指南。

server 指令有几个参数,您可以用来调整服务器行为。在此示例中,我们更改了 NGINX 用于确定服务器是否不健康并因此不符合接收请求资格的条件。在这里,如果通信尝试在每个 2 秒内失败一次(而不是默认的 10 秒内失败一次),则认为服务器不健康。

我们将此设置与 proxy_next_upstream 指令结合使用,以配置 NGINX 认为通信尝试失败的情况,在这种情况下,它将请求传递给上游组中的下一个服务器。对于默认的错误和超时条件,我们添加了 http_500,以便 NGINX 认为来自上游服务器的 HTTP 500(内部服务器错误)代码表示尝试失败。

keepalive 指令设置了每个工作进程缓存中保留的到上游服务器的空闲保持连接数。我们在错误 3 中已经讨论了启用与上游服务器的保持连接的好处。

使用 NGINX Plus,您可以配置上游组的其他功能:

我们前面提到,NGINX Open Source 仅在启动期间解析服务器主机名到 IP 地址。resolve 参数使 NGINX Plus 能够监控与上游服务器域名对应的 IP 地址的更改,并自动修改上游配置,而无需重新启动。

service 参数进一步使 NGINX Plus 能够使用 DNS SRV 记录,这些记录包含有关端口号、权重和优先级的信息。这在微服务环境中至关重要,因为服务的端口号通常是动态分配的。

有关解析服务器地址的更多信息,请参阅我们博客上的《使用 DNS 进行服务发现与 NGINX 和 NGINX Plus》。

slow_start 参数使 NGINX Plus 能够逐渐增加其发送到刚被认为健康且可接受请求的服务器的请求量。这可以防止突然的请求激增可能导致服务器再次失败。

queue 指令使 NGINX Plus 能够在无法选择上游服务器来处理请求时将请求放入队列,而不是立即返回错误给客户端。